fix(swift-sdk): act on swept transactions in the SwiftData store - #4589
Conversation
|
Warning Review limit reachedNext included review available in 24 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (26)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (11)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe Swift SDK promotes the live persistence schema to V4, adds sweep and ChainLock fields, preserves earlier model shapes, registers lightweight migration, exposes new persistence capabilities, and expands migration and persistence tests. ChangesSwift persistence schema and sweep state
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The schema migration, persistence-model updates, and capability coverage present no supported merge-blocking risk. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🕓 Queued for automated review — 65th in line, estimated start in ~91 h (commit 7ea9be2)
|
267ecca to
48db83c
Compare
8918079 to
1fb1a17
Compare
48db83c to
f4053ad
Compare
1fb1a17 to
73a9ea7
Compare
f4053ad to
ec4ec10
Compare
73a9ea7 to
39352b7
Compare
ec4ec10 to
3b6c526
Compare
39352b7 to
f281807
Compare
Bumps the rust-dashcore pin to dev and projects the `TransactionsSwept` event the bump brings with it. The two halves are one commit by construction: `WalletEvent` is not `#[non_exhaustive]` and platform has four exhaustive matches over it, so new-pin code cannot compile without the arms — and arms that did nothing would be worse than none, because upstream's removal is unconditional. The wallet drops the losing rows in memory; a store that keeps them replays them at the next load and re-creates the phantom balance the upstream fix exists to kill. The projection is one `SweepBatch` per event, and a sweep-only round is counted in `is_empty_no_records` so a round carrying nothing but a sweep still reaches the persister. The gate is what makes every intermediate host state safe. A backend that has not attested `CORE_SWEEP_REMOVAL` is not known to have applied the round's subtractive half, so its watermark is stripped BEFORE the store and the wallet faults exactly as it would on a rejection — reporting the height durable first and faulting after cannot retract a height a legacy backend already committed. Such a host freezes its sync watermark on the first sweep it meets instead of diverging: fail-closed, funds-safe, and unfrozen the moment its persister ships. A record arriving after a sweep of the same txid retracts that txid from the folded sweep, since persisters write records before replaying sweeps and would otherwise delete a row the wallet has brought back. The asset-lock half mirrors it: a sweep removes the tracked entry its funding transaction created, and `AssetLockChangeSet::merge` now cancels a folded tombstone against a reinstating upsert (and vice versa), so no store ever sees an upsert/tombstone pair for one outpoint whose outcome depends on which it applies first. The pin also carries rust-dashcore#981, which collapses BIP-39 parsing onto one auto-detecting path. Platform's four hand-rolled "try every wordlist" helpers are now that function, and the call sites drop their `Language` argument. It is unrelated to sweeps and rides here only because the sweep chain and the payload-finalization seam this branch's base already depends on both sit above it on dev. `spend_observer`'s two projections gain sweep arms that report no observed spend: a sweep's released outpoints are coins that came back free, and the inputs it kept spent are precisely the ones it does not name, so the held set cannot be derived from the event at all.
…ouched `cargo fmt --check --all` is a CI gate and the collapsed `Mnemonic::from_phrase` calls left two of them wrapped.
Review nits, all documentation. `parse_mnemonic_any_language`'s doc still said `key_wallet::Mnemonic` "only exposes language-tagged constructors" and that callers "must walk the language list themselves" — precisely what rust-dashcore#981 removed, and it contradicted the inline comment three lines below. The wrapper is kept: 20 call sites narrow upstream's error to the `&'static str` they report, and that narrowing is now what the doc says it does. The sweep gate's recovery note read as if a capable backend might appear mid-session. It cannot: the persister does not change under a running adapter, so a host without the slot stays frozen until it ships one and relaunches. Freezing is the point. `last_processed_height` is now documented as deliberately NOT stripped beside `synced_height`, matching the #4069 guard: `synced_height` is the durable "scanned AND persisted" claim that must not outrun an unapplied removal, while `last_processed_height` is the adapter's own progress marker whose retention makes nothing safer. And the asset-lock test's `DASHPAY_PAYMENTS` attestation no longer describes an overlay this PR writes — nothing here stages `dashpay_payments_overlay`; the bit is declared so the fixture still describes a fully capable backend once #4442 lands. Not taken: de-indenting the vestigial block in `commit_wallet`. It spans 152 lines, so removing it would bury the reviewable diff under a whitespace-only change and force another rebase of the four PRs stacked above this one.
…reason CI lints these crates with `-D warnings`, so clippy's seven-argument threshold is an error, and the #4370 merge gave `commit_wallet` an eighth: the `settled` set the panic arm in `run_wallet_event_adapter` reads back to decide which wallets have an unknown outcome. Every parameter is a distinct piece of drain state this function reads and writes, and the borrow split is what keeps them separately mutable — bundling them would rename the same eight.
The SwiftData mirror of the storage contract, complicated by two things SQLite does not have: rows shared across wallets, and a round that now spans two callbacks. Shared rows are why a sweep marks rather than deletes. A transaction row can belong to several wallets, so the first wallet's callback cannot remove it — it sets `isGloballySwept`, which excludes the row and its outputs from every restore and enumeration path, and the physical delete is left to housekeeping once every wallet's scoped cleanup has landed. A tombstone must likewise outlive its loser: detach it and the consumed coin reads unspent again. Held inputs become pending-input tombstones carrying the winner and, when it was mined, its height; a chained sweep repoints an earlier tombstone at the new winner rather than stacking a second hold. The release pass is outpoint-keyed, the drain gives tombstones precedence over ordinary observations, and `isSpent` stays monotonic against them: a hold the sweep proved consumed is never downgraded by a later record — not even the winner's own, which can arrive IS-locked, a context below in-block. `autosaveEnabled` goes off on the round context. Sweeps travel in their own callback, so the round spans two calls, and an autosave landing between them would make the watermark and the additive rows durable while the removal is still unstaged — with `rollback()` unable to take back a save that already happened. The handler attests `ATOMIC_CHANGESETS`, and Rust now relies on that to trust the split transport, so the guarantee has to be real. The handler declares `CORE_SWEEP_REMOVAL` and `DASHPAY_PAYMENTS`; before this commit it published the legacy `struct_size`, the negotiated slot read `None`, and Rust fail-closed. The four models that gain a column — `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput`, `PersistentWallet` — were still referenced live by `DashSchemaV1/V2/V3`. Adding a property to a live model mutates those released versions' checksums in place, so a store written by a shipped binary would match no registered schema and fail to open with Cocoa 134504 instead of migrating. That is exactly the defect `DashSchemaFrozenModels.swift` was introduced to prevent, and its instruction is to freeze the model you change. Freezing those four alone is not possible: a frozen model declares its relationships against frozen counterparts (an `inverse:` key path is typed on the destination model), and following relationships in both directions closes over 24 of the 35 models — one type per entity name is all a schema can hold, so the component travels together. All 24 are frozen here at their V3 shape, shared by V1, V2 and V3, none of which changed any of them. The eleven models outside the component are still live-referenced and still carry the latent defect, unchanged by this. `DashSchemaV4` then registers the live models with a lightweight V3→V4 stage: every new column is additive with a default or optional, so existing rows migrate as not-swept, unsuperseded, ordinary unstamped claims, and a wallet with no chainlock boundary yet. `reconcileSpendObservation` stays the single spend verdict, extended with one sweep term — a stamped hold outranks any observation — and its oldest-first pending-row reconciliation stays, under a tombstone-precedence branch. One correction the merge forced: the "never displace confirmed evidence" rule refused the link when `isSpent` was true with NO spender linked, which is precisely the sweep-hold shape, so the winner's own record could never supply the attribution the hold lacked. With no link there is nothing to displace, so it is adopted. One gap neither PR covered is closed here: `buildUnresolvedAssetLockTxRecordBuffer` now skips globally-swept rows, so the double-spend screen can never be handed a swept loser as the settled spender of a lock's input. Also carries the `ChangesetRoundIndex` per-round fetch cache — the reviewed-but-untested fix for the quadratic SwiftData fetch that put ~99% of CPU on the serial queue. Sweep paths deliberately opt out of it, since they key on mutable columns the index cannot answer stale. Tests: `SweptTransactionPersistTests` (38) — shared losers, detached tombstones with a missing winner row, chained tombstones, cross-round reinstatement, released-pending deadlock, co-swept twins, the throwing-lookup round failure, and the winner's late record against a stamped hold. `DashModelMigrationTests` gains the V3→V4 stage and reads V1/V2 rows through the frozen types. Full suite: 437 tests, the only failures being two `KeychainSignerAdditionalSigningKeysTests` cases that fail identically on an unmodified checkout (the test host cannot write to the keychain).
3b6c526 to
66a7c74
Compare
f281807 to
27c7c08
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
llbartekll
left a comment
There was a problem hiding this comment.
Reviewed this PR's own diff only (base split/4406-3-producer), against the four things it sets out to do: the sweep application path, autosaveEnabled = false, schema V4 + the freeze, and the round index.
The freeze checks out. I compared all 24 frozen copies against the live models on the base branch mechanically — property names, types, defaults, @Attribute, #Index and @Relationship markers all match; the only textual differences are the public modifiers, which are not schema inputs, plus the expected V1 PersistentAssetLock delta. componentFrozenModelTypes is positionally identical to allModelTypes (34 entries, assetLock in the same slot). I also checked txid byte order across the new sweep path (withUnsafeBytes on the FFI tuple) against the record path's hashData — both are raw bytes, so sweep txids match stored ones. That was the highest-risk part of the change and it is sound.
Two things I'd like addressed before this merges:
- Seven mangled lines from what looks like an automated edit — two of them in
persistTrackedMasternodes, which this PR does not otherwise touch. Cosmetic, but they shouldn't land. settledSpenderLinkIsKeptis never called, and the rule it documents is absent from the live path. Details inline — this is the only finding with correctness weight.
The rest (tombstone collector scan cost, the unbracketed payments path under autosave-off, two test gaps) is non-blocking — take or leave as follow-ups.
I did not run xcodebuild test on my side, so the 437-test claim is taken at face value.
…e per wallet Review follow-ups on the SwiftData sweep writer, brought onto the same doctrine as the SQLite store (#4559). Every rule below is a property of the coin, not of the row's relationships. The hold is keyed by outpoint. `applySweptTransaction` decodes the loser's inputs from its stored bytes and settles each one by key; a spender link is detached only if it points at the loser. Before, the sweep walked the loser's `inputs` relationship, which a store-only fetch had refreshed to its saved state — so a winner recorded in the same round as the sweep of its loser had its freshly written link nil-ed, and `walletFundedTransaction` never saw the winner again. The one link writer, `adoptSpendObservation`, now registers the displaced spender in the round index, so no keyed store-only lookup can refresh an object carrying staged state. The hold is global, the release is per wallet. The first callback that sees a sweep holds every wallet's rows for the loser's inputs, then deletes the loser's row; each wallet's own callback applies its release set to its own rows. The loser's outputs are dead for everyone and its inputs' holds are a txid fact, not a per-wallet one — only the release set depends on which records a wallet holds. That removes `isGloballySwept`, the deferred delete and every reader guard built to hide a surviving swept row; a swept row no longer enumerates through `involvedTransactions`, and a wallet whose round is rejected finds its coin held rather than restorable. Pending rows are per (outpoint, spendingTxid, walletId), so a second wallet recording the same spend keeps its own claim row; the drain prefers the tombstone tagged with the delivering wallet. A drained tombstone stamps — `isSpent`, `supersededByTxid` — and never mints a spender link or a vin index; the winner's own claim row beside it supplies both. A release is vetoed by a stored network-final claim whose bytes actually spend the outpoint (a stamp alone does not veto: under the global hold it lands on every non-released input). The settled-link guard is wired for real: `reconcileSpendObservation` takes the existing spender's context, `isSpent` is monotonic on both channels, and a stamped, unlinked coin the wallet re-delivers unspent follows the wallet — refusing would lock a real coin out of every future restore after a reorg of its winner. The collector runs once per round, from `endChangeset`, after every account slice and every sweep, on the boundary the round's own writes left on the wallet row. Before, it ran in the header — before this round's `utxos_added` — so a funding output arriving in the round that completed the boundary found its tombstone already deleted and landed unspent. The store query now selects tombstones only, with an index on `[walletId, isSweptTombstone]` in the V4 stage. Also: out-of-round save failures roll the context back and log when the round index has to fall back; the seven `print` sites go through `SDKLogger`; `hashData(_:)`, one tombstone-scan helper and one wallet-lookup preamble replace the inline copies; the seven collapsed newlines are restored; the V4 columns are documented under V4 and the frozen-models header describes what is actually frozen; the migration test asserts V3 and V4 name the same entity set. Tests: `SweptTransactionPersistTests` 38 → 50, eighteen of them red on the pre-fix handler; `swift test` 461 passed.
…se per wallet Review follow-ups on the Room sweep writer, brought onto the same doctrine as the SQLite store (#4559) and the Swift port (#4589). Every rule below is a property of the coin, not of a row's foreign key. The hold is keyed by outpoint. The sweep decodes the loser's inputs from its stored bytes (`StoredTransactionInputs`, key-wallet's `transaction_decode`, txid verified, undecodable fails the round closed) and holds each one by key: `isSpent = 1`, `supersededByTxid = winner`, any non-loser spender link kept. Before, the hold was `UPDATE … WHERE spendingTxid = loser`, so a winner whose own record landed in the same round — the common path for the wallet's own double-spends — had already taken the link, nothing matched, and the coin restored as spendable after a restart until the winner mined. The hold is global, the release is per wallet. The first callback that sees a sweep holds every wallet's rows for the loser's inputs, then deletes the loser's row (hold before delete, so the FK `SET NULL` and cascade only clear links); each wallet's own callback applies its release set to its own rows, by outpoint. That removes `isGloballySwept` — column, migration, flag maintenance — `hasOtherWalletClaim` and its probes, the deferred delete and every reader guard built to hide a surviving swept row. A wallet whose round is rejected now finds its coin held rather than restorable, which is what the class doc claimed. Pending rows are per (outpoint, spendingTxid, walletId); the drain prefers the delivering wallet's tombstone. A drained tombstone stamps and never mints a spender link, so a later release can still free the coin. A release is vetoed by a stored network-final claim — a linked spender, or a stamp whose stored bytes actually spend the outpoint. One `linkSpender` serves the record, `utxos_spent` and drain channels; `isSpent` is monotonic on all of them; a stamped, unlinked coin the wallet re-delivers unspent follows the wallet. The found-TXO branch deletes only the arriving txid's pending rows when the existing link is kept. Batches are buffered per round and applied together, so the co-swept set spans the round, and the collector runs once from `onChangesetEnd` after every slice and sweep — before, it ran in the header, ahead of the round's own `utxos_added`. The chainlock height is a narrow monotonic `UPDATE`. Per-loser statement fan-out is replaced by chunked `IN (:chunk)` forms and rowid-keyed pending writes. Schema: one `MIGRATION_10_11` (four columns, two indexes on `pending_inputs`), version 11, `11.json` regenerated by Room. JNI: the sweep slot ships flat `[B` arrays with counts, one `supersededBy`, and an explicit `(hasWinnerMinedHeight, winnerMinedHeight)` pair — descriptor `([B[BI[B[BIZI)I`, pinned by a unit test. Heights cross the boundary through a checked `u32 → Int` conversion that fails the round closed rather than wrapping negative. The sweep slot is wired only when the concrete bridge overrides the method. Every ported KDoc cites its Swift source; the "unreachable on this channel" clause is gone; the changeset → chainlock-height → sweeps order is stated once. Tests: handler class 133 → 150, `DashDatabaseTest` 10 → 12, the behavioural cases red with the pre-fix behaviour re-introduced; 422 passed. `rs-unified-sdk-jni` 39 passed.
…del from V1 Two gaps from review. The settled-link guard's own case — a plain in-block arrival against a spender that is only IS-locked — was covered only through the mempool variant, which the pre-existing `isSpent` branch would have refused anyway. The new case delivers the conflicting record at context 2 against an unmined IS-locked spender, checks the link stays and the coin stays spent through the following sweep's release, and pins chainlock-over-IS-lock as the one takeover. The V3 → V4 migration test wrote only a wallet and a pending row, so two of the four widened models never crossed the stage; it now carries a transaction and a spent coin through and asserts the stamp backfills to nil. A new V1 → V4 case migrates a wallet, a transaction and a coin from the oldest registered version, including the coin's relationship to its funding transaction — the freeze pinned where it matters.
|
@llbartekll thanks — both blockers and all four follow-ups are addressed, replies inline. Two commits:
|
llbartekll
left a comment
There was a problem hiding this comment.
Approving. Re-reviewed f0fdcc1da7 + a93c4190a0 in full — this went well past fixing the seven points.
On my findings:
- Formatting — all seven restored, and
persistTrackedMasternodesis out of the diff. Re-scanned the file for glued braces and run-on continuations: clean. settledSpenderLinkIsKept— now actually wired:reconcileSpendObservationtakes the existing spender's context and consults it, and every channel (record pass,utxos_spentemit, pending drain) goes through one link writer inadoptSpendObservation.testAnInBlockArrivalDoesNotTakeTheLinkFromAnInstantSendLockedSpenderpins exactly the case that fell through before, including the release veto in the following sweep, with the chainlocked takeover in the second half.- Collector — once per round from
endChangeset, gated on a round that actually moved a boundary half, and the scan now selectsisSweptTombstone == truebehind a new(walletId, isSweptTombstone)index instead of materialising every pending row. Worth more than the perf note: moving it after the slices caught a real funds bug (the boundary-completing round deleting a tombstone before the same round'sutxos_addedcould drain it), now pinned bytestAFundingOutputDeliveredInTheRoundThatCompletesTheBoundaryStillDrainsItsTombstone. - Unbracketed payments path — checked against the FFI, and both consequences are now non-silent (rollback on a failed out-of-round save,
persistence_round_index_disabledwhen a dirty context costs the round its index). - Migration tests —
testV1StoreWithWalletTransactionAndCoinMigratesToV4covers what I asked for, andtestV3AndV4NameTheSameEntitySetpins the entity-set invariant the redesign now depends on.
On the redesign itself (dropping isGloballySwept, keying the hold on the outpoint, holding globally and releasing per wallet): I read it end to end rather than treating it as a fixup, and it holds together — the hold now survives the row rather than depending on it, so the delete is unconditional and the cross-wallet ordering problem the flag existed to paper over is gone, along with the deadlock it needed the released-pending special case for. Keying off the loser's decoded inputs instead of row.inputs closes the case where the link had already moved to the winner. releaseIsVetoed reads as the right place for that check, and failing closed on a network-final claimant whose bytes don't decode is the correct direction to fail.
I re-ran the mechanical check on the new state: all 25 frozen copies still match the live models on the base branch exactly — attributes, optionality, defaults, @Attribute/#Index/@Relationship markers — with only the documented V2 asset-lock delta. Entity set unchanged from V3, so the schema story survives PersistentTransaction no longer being widened.
One thing I'd flag without blocking, since it is a deliberate semantic reversal from the first revision and your call as the author of the storage contract: isSpent is now monotonic on the observation channels, with utxos_added re-delivery as the single path down — the old reorg-demotion lowering (re-observing the linked spender at a lower context) is gone, and a stamped materialised hold now frees on re-delivery instead of refusing it. The reasoning in the property docs is sound (the wallet knows the coin, so BIP158 prevout matching re-discovers any network-final spender, and refusing would strand a real coin after a reorg of the winner), and both directions are pinned — testWalletReDeliveringAMaterialisedHeldCoinFreesIt and testWalletReDeliveringACoinLinkedToASettledSpenderKeepsItSpent. Just worth being deliberate that the safety of the whole thing now rests on the wallet re-emitting a coin it holds unspent.
Test count went 38 → 51 on the sweep suite. I still haven't run xcodebuild test locally, and I notice CI has no Swift job (Kotlin + the explorer-model check are what run here), so the suite result is on your word — nothing in this review depends on it.
Co-authored-by: Roman <51091564+jeanpierreroma@users.noreply.github.com>
Brings in the squash-merged producer (#4560) this branch was stacked on, plus #4584. Conflicts in changeset.rs and core_bridge.rs were this branch's copies of the producer commits against their squash; this branch never touched either file, so the base version was taken and the tree outside packages/swift-sdk is identical to v4.2-dev.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## v4.2-dev #4589 +/- ##
============================================
+ Coverage 85.70% 85.91% +0.20%
============================================
Files 2764 2794 +30
Lines 367624 370651 +3027
============================================
+ Hits 315076 318432 +3356
+ Misses 52548 52219 -329
🚀 New features to boost your workflow:
|
`cargo fmt --check` is a CI gate; the checked `u32 -> Int` conversion added for the sweep and header slots was hand-written.
Issue being fixed or feature implemented
Until this lands, the Swift host publishes the legacy
struct_size, the negotiated sweeps slot readsNone,CORE_SWEEP_REMOVALis withheld, and Rust fail-closes: an iOS wallet freezes its sync watermark on the first sweep it meets rather than diverging. Funds-safe, but a user-visible stall — this is the PR that ends it.What was done?
The store
SwiftData rows can be shared across wallets, so a sweep marks rather than deletes:
isGloballySweptexcludes the row and its outputs from every restore and enumeration path, and the physical delete is left to housekeeping once every wallet's scoped cleanup has landed. A tombstone must likewise outlive its loser — detach it and the consumed coin reads unspent again.Held inputs become pending-input tombstones carrying the winner and, when it was mined, its height. A chained sweep repoints an earlier tombstone at the new winner rather than stacking a second hold. The release pass is outpoint-keyed, the drain gives tombstones precedence over ordinary observations, and
isSpentstays monotonic against them: a hold the sweep proved consumed is never downgraded by a later record — not even the winner's own, which can arrive IS-locked, a context below in-block.autosaveEnabledgoes off on the round context. Sweeps travel in their own callback, so a round now spans two calls, and an autosave landing between them would make the watermark and the additive rows durable while the removal is still unstaged — withrollback()unable to take back a save that already happened. The handler attestsATOMIC_CHANGESETSand Rust relies on that to trust the split transport, so the guarantee has to be real.Schema V4, and the freeze it required
The four models that gain a column (
PersistentTransaction,PersistentTxo,PersistentPendingInput,PersistentWallet) were still referenced live byDashSchemaV1/V2/V3. Adding a property to a live model mutates those released versions' checksums in place, so a store written by a shipped binary matches no registered schema and fails to open with Cocoa 134504 instead of migrating — exactly whatDashSchemaFrozenModels.swiftwas introduced to prevent, and its instruction is to freeze the model you change.Freezing those four alone is not possible: a frozen model declares its relationships against frozen counterparts (an
inverse:key path is typed on the destination model), and following relationships in both directions closes over 24 of the 35 models — a schema holds one type per entity name, so the component travels together. All 24 are frozen at their V3 shape, shared by V1/V2/V3, none of which changed any of them. The eleven models outside the component remain live-referenced and still carry the latent defect, unchanged by this PR.DashSchemaV4then registers the live models with a lightweight V3→V4 stage: every new column is additive with a default or optional, so existing rows migrate as not-swept, unsuperseded, ordinary unstamped claims, and a wallet with no chainlock boundary yet.Merge with #4356
#4356 landed first and rewrote the same three regions. Its
reconcileSpendObservationstays the single spend verdict, extended with one sweep term — a stamped hold outranks any observation — and its oldest-first pending-row reconciliation stays, under a tombstone-precedence branch.One correction the merge forced: the "never displace confirmed evidence" rule refused the link when
isSpentwas true with no spender linked, which is precisely the sweep-hold shape, so the winner's own record could never supply the attribution the hold lacked. With no link there is nothing to displace, so it is adopted.One gap neither PR covered is closed here:
buildUnresolvedAssetLockTxRecordBufferskips globally-swept rows, so the double-spend screen can never be handed a swept loser as the settled spender of a lock's input.Also carried
The
ChangesetRoundIndexper-round fetch cache — the reviewed-but-untested fix for the quadratic SwiftData fetch that put ~99% of CPU on the serial queue. Sweep paths deliberately opt out of it, since they key on mutable columns the index cannot answer stale. Named explicitly because it is the one piece here without dedicated tests.How Has This Been Tested?
xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17'— 437 tests, andswift buildclean under the package's-warnings-as-errors.SweptTransactionPersistTests(38): shared losers, detached tombstones with a missing winner row, chained tombstones, cross-round reinstatement, released-pending deadlock, co-swept twins, the throwing-lookup round failure, and the winner's late record against a stamped hold.DashModelMigrationTests: gainstestV3StoreMigratesToV4AndBackfillsTheSweepColumns, and its V1/V2 cases now write and read through the frozen types.InvitationPersistenceTeststracks the new capability mask.The only failures on this machine are two
KeychainSignerAdditionalSigningKeysTestscases, which fail identically on an unmodified checkout — the barexcodebuildrun has no writable keychain, whichrun_tests.shprovides in CI.Breaking Changes
None at the API surface. Schema V4 is a lightweight migration; the freeze exists specifically so V1/V2/V3 stores keep opening.
Checklist:
For repository code-owners and collaborators only
Summary by CodeRabbit
New Features
Bug Fixes
Migration